Readiness score: detail screen with contributor breakdown and trend (3/5) - #108
Open
ak710 wants to merge 4 commits into
Open
Readiness score: detail screen with contributor breakdown and trend (3/5)#108ak710 wants to merge 4 commits into
ak710 wants to merge 4 commits into
Conversation
First of several PRs implementing the readiness/recovery score from the roadmap's "Metrics you can trust" section (saksham2001#103). This one lands the engine and its storage; the Today tile, detail screen, coach tool, and widget follow separately. A daily 0-100 score from five contributors, weighted 30/25/30/10/5: overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's training load. Four are judged against the user's own baseline; sleep is absolute because SleepScore already encodes population-normal ranges. Three rules shape the design: - Missing signals leave the denominator rather than scoring zero. A night without a temperature reading is scored out of 90 points, not penalised 10, and the result reports its coverage. This mirrors the doctrine at the top of SleepInsights.swift. - An unestablished baseline counts as missing, not as "at baseline". Scoring a deviation against three days of data would look authoritative while being noise. - Every contributor carries its own explanation ("HRV 12% below your baseline"), so the score is never surfaced as a bare number. The full algorithm - every weight and threshold - is documented in docs/project/readiness.md. Reuses the existing baseline machinery rather than building a parallel one: BaselineStats for HRV and temperature, and UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns and throttles. ReadinessService is shaped after that service. Overnight signals are read from the sleep session's own span, falling back to 22:00-08:00 when sleep wasn't decoded, so daytime readings can't masquerade as recovery data. Scores persist as ReadinessDaily with their breakdown, since recomputing an old morning against today's baseline would give a different and wrong answer. Rows carry an algorithmVersion that invalidates them on a weight change instead of silently reinterpreting them. Archive format version goes to 2. readinessDailies is Optional because PulseArchive uses the synthesized decoder, which has no notion of property defaults - a non-optional array would make every existing v1 backup unimportable. Covered by a test that strips the key from a real export. 39 new tests. Demo seed data produces 10 scored days across multiple bands, so the feature is reviewable without a ring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second readiness PR (saksham2001#103), building on the scoring engine. Puts the score on screen as a full-width card pinned directly under the Today hero, above the metric grid, with its own Settings page. Deliberately NOT a tile in the reorderable grid. Every other tile reports one measurement; readiness is a verdict *over* those measurements - HRV, resting HR, sleep, temperature and yesterday's load collapsed into a single number. Placing it beside a peer tile framed it as a sibling metric, which is the wrong mental model, and half a tile's width could not carry the reasoning that stops it being a black box. So it carries no MetricKey at all: it is pinned by design, and visibility is the Settings toggle's job rather than the drag tray's. The width earns its keep. The card shows the score, its band, the share of signals it is based on, and the top two contributors holding it back - each with its own explanation and what it cost, e.g. "Skin temperature 0.5 °C above your baseline, −3.4 pts". The empty state counts down instead of repeating an instruction. Readiness cannot say anything until it knows what *your* normal looks like, and a flat "wear your ring overnight" gives no sense of whether that means one more night or two more weeks - on a device with a month of history it reads as broken. The card now shows a filling ring with "3 of 7 nights" and "4 more nights to go", so the feature visibly works before it can produce a score. Progress counts nights that actually produced overnight signal, not days since install: someone who wore the ring twice in a month is two nights along, and saying otherwise would promise a score that isn't coming. A night whose sleep decode failed still counts if vitals were captured. And because nights are a proxy for BaselineStats.isEstablished - which also requires enough individual readings - the copy never claims "0 more nights" while still showing no score; it says it is still gathering readings. A test pins the advertised night count against the gate itself, so the countdown cannot drift from what actually unblocks a score. This also fixes a real gap the previous copy had: it keyed off the pairing calibration state, so a long-established user who simply lacked a readiness baseline saw a bare "No score yet" with no path forward. The card is gated on its master toggle plus "can this ring measure recovery at all" - HRV or sleep, the two signals ReadinessScore requires. A ring with neither could never produce a score, so the card is absent rather than permanently empty. Band zones live in ReadinessZones rather than on a view, because the card, the detail hero and the trend chart all need them and none should depend on another's type name. A test pins them against ReadinessScore.band. TodayStore needed two changes. ReadinessService.refreshIfStale runs BEFORE the signature is captured, not after - otherwise the write lands after the snapshot and forces a second, wasted rebuild. And the signature gained a readiness clause; without it a freshly scored night sits in the database while the card keeps showing yesterday's. Progress is computed only while there is no score, so it never runs on the happy path. Also adds an -openReadinessSettings launch arg, matching the existing nutrition test tooling. 25 new tests. Layout verified in the simulator against seeded demo data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two inline contributor JSON literals ran past 200 characters, which is SwiftLint's error threshold and was failing CI. Hoist both into named multiline constants, matching how the other readiness tests already carry their fixtures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third readiness PR (saksham2001#103). The tile can only name the single biggest drag; this screen accounts for every contributor, so a user can reconstruct the arithmetic behind the number. That is what "documented metrics, no black boxes" has to mean in practice. Layout: hero ring with an explicit coverage line, the full breakdown sorted by drag (each contributor showing its earned/possible points as a bar plus its own explanation), what wasn't measured, a 7/30/90-day trend chart, and an explainer pointing at docs/project/readiness.md. Two deliberate choices about honesty: - Coverage is stated on the hero whenever it is below 100%. A 74 from a partial night is not the same claim as a 74 from a complete one, and showing only the number would quietly equate them. - Absent signals are named, not omitted. "Your temperature was fine" and "your temperature wasn't measured" are different statements; a "Not measured last night" section says which one applies, with a line explaining they are left out of the score rather than counted as zero. The trend chart is the first chart in this app to be accessible to VoiceOver. It ships an AXChartDescriptor plus per-bar labels, so the rotor can step through days and hear "24 July, 91, Primed" instead of just "chart". Charts.swift, VitalsCharts.swift and ActivityCharts.swift are all still opaque; this is the pattern to back-port. Hardening that path found a real crash. The axis description closure is called by the framework with values the app doesn't control, and Int(Double) traps on infinity and NaN - so an unguarded conversion took the whole app down, and only ever for VoiceOver users. Now guarded and clamped, with a regression test that feeds it infinity, NaN and out-of-range values. missingKinds moved from the view onto ReadinessSnapshot: it is not presentation logic, it is a fact about the score, and PR 4's coach tool needs the same answer. Also adds an -openReadiness launch arg, matching the existing test tooling. 12 new tests. 938 total, 0 failures. Verified rendering in the simulator against seeded demo data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third readiness PR (#103), building on the Today card in #105. This is the follow-up listed there as "3. Detail screen".
The tile can only name the single biggest drag; this screen accounts for every contributor, so a user can reconstruct the arithmetic behind the number. That is what "documented metrics, no black boxes" has to mean in practice.
Layout: hero ring with an explicit coverage line, the full breakdown sorted by drag (each contributor showing its earned/possible points as a bar plus its own explanation), what wasn't measured, a 7/30/90-day trend chart, and an explainer pointing at
docs/project/readiness.md.Two deliberate choices about honesty
The first accessible chart in this app
The trend chart ships an
AXChartDescriptorplus per-bar labels, so the VoiceOver rotor can step through days and hear "24 July, 91, Primed" instead of just "chart".Charts.swift,VitalsCharts.swiftandActivityCharts.swiftare all still opaque; this is the pattern to back-port.Hardening that path found a real crash. The axis description closure is called by the framework with values the app doesn't control, and
Int(Double)traps on infinity and NaN — so an unguarded conversion took the whole app down, and only ever for VoiceOver users. Now guarded and clamped, with a regression test that feeds it infinity, NaN and out-of-range values.Also in here
missingKindsmoved from the view ontoReadinessSnapshot: it is not presentation logic, it is a fact about the score, and PR 4's coach tool needs the same answer.Adds an
-openReadinesslaunch arg, matching the existing test tooling.12 new tests. Verified rendering in the simulator against seeded demo data.